/*
 * HelpController.java 20 juil. 07
 *
 * Sweet Home 3D, Copyright (c) 2007 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.viewcontroller;

import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.ref.WeakReference;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.swing.text.BadLocationException;
import javax.swing.text.ChangedCharSetException;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTML.Tag;
import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;

import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.tools.ResourceURLContent;

/**
 * A MVC controller for Sweet Home 3D help view.
 * @author Emmanuel Puybaret
 */
public class HelpController implements Controller
{
	/**
	 * The properties that may be edited by the view associated to this controller. 
	 */
	public enum Property
	{
		HELP_PAGE, BROWSER_PAGE, PREVIOUS_PAGE_ENABLED, NEXT_PAGE_ENABLED, HIGHLIGHTED_TEXT
	}
	
	private static final String SEARCH_RESULT_PROTOCOL = "search";
	
	private final UserPreferences preferences;
	private final ViewFactory viewFactory;
	private final PropertyChangeSupport propertyChangeSupport;
	private final List<URL> history;
	private int historyIndex;
	private HelpView helpView;
	
	private URL helpPage;
	private URL browserPage;
	private boolean previousPageEnabled;
	private boolean nextPageEnabled;
	private String highlightedText;
	
	public HelpController(UserPreferences preferences, ViewFactory viewFactory)
	{
		this.preferences = preferences;
		this.viewFactory = viewFactory;
		this.propertyChangeSupport = new PropertyChangeSupport(this);
		this.history = new ArrayList<URL>();
		this.historyIndex = -1;
		showPage(getHelpIndexPageURL());
	}
	
	/**
	 * Returns the view associated with this controller.
	 */
	public HelpView getView()
	{
		if (this.helpView == null)
		{
			this.helpView = this.viewFactory.createHelpView(this.preferences, this);
			addLanguageListener(this.preferences);
		}
		return this.helpView;
	}
	
	/**
	 * Displays the help view controlled by this controller. 
	 */
	public void displayView()
	{
		getView().displayView();
	}
	
	/**
	 * Adds the property change <code>listener</code> in parameter to this controller.
	 */
	public void addPropertyChangeListener(Property property, PropertyChangeListener listener)
	{
		this.propertyChangeSupport.addPropertyChangeListener(property.name(), listener);
	}
	
	/**
	 * Removes the property change <code>listener</code> in parameter from this controller.
	 */
	public void removePropertyChangeListener(Property property, PropertyChangeListener listener)
	{
		this.propertyChangeSupport.removePropertyChangeListener(property.name(), listener);
	}
	
	/**
	 * Sets the current page.
	 */
	private void setHelpPage(URL helpPage)
	{
		if (helpPage != this.helpPage)
		{
			URL oldHelpPage = this.helpPage;
			this.helpPage = helpPage;
			this.propertyChangeSupport.firePropertyChange(Property.HELP_PAGE.name(), oldHelpPage, helpPage);
		}
	}
	
	/**
	 * Returns the current page.
	 */
	public URL getHelpPage()
	{
		return this.helpPage;
	}
	
	/**
	 * Sets the browser page.
	 */
	private void setBrowserPage(URL browserPage)
	{
		if (browserPage != this.browserPage)
		{
			URL oldBrowserPage = this.browserPage;
			this.browserPage = browserPage;
			this.propertyChangeSupport.firePropertyChange(Property.BROWSER_PAGE.name(), oldBrowserPage, browserPage);
		}
	}
	
	/**
	 * Returns the browser page.
	 */
	public URL getBrowserPage()
	{
		return this.browserPage;
	}
	
	/**
	 * Sets whether a previous page is available or not.
	 */
	private void setPreviousPageEnabled(boolean previousPageEnabled)
	{
		if (previousPageEnabled != this.previousPageEnabled)
		{
			this.previousPageEnabled = previousPageEnabled;
			this.propertyChangeSupport.firePropertyChange(Property.PREVIOUS_PAGE_ENABLED.name(), !previousPageEnabled,
					previousPageEnabled);
		}
	}
	
	/**
	 * Returns whether a previous page is available or not.
	 */
	public boolean isPreviousPageEnabled()
	{
		return this.previousPageEnabled;
	}
	
	/**
	 * Sets whether a next page is available or not.
	 */
	private void setNextPageEnabled(boolean nextPageEnabled)
	{
		if (nextPageEnabled != this.nextPageEnabled)
		{
			this.nextPageEnabled = nextPageEnabled;
			this.propertyChangeSupport.firePropertyChange(Property.NEXT_PAGE_ENABLED.name(), !nextPageEnabled,
					nextPageEnabled);
		}
	}
	
	/**
	 * Returns whether a next page is available or not.
	 */
	public boolean isNextPageEnabled()
	{
		return this.nextPageEnabled;
	}
	
	/**
	 * Sets the highlighted text.
	 */
	public void setHighlightedText(String highlightedText)
	{
		if (highlightedText != this.highlightedText
				&& (highlightedText == null || !highlightedText.equals(this.highlightedText)))
		{
			String oldHighlightedText = this.highlightedText;
			this.highlightedText = highlightedText;
			this.propertyChangeSupport.firePropertyChange(Property.HIGHLIGHTED_TEXT.name(), oldHighlightedText,
					highlightedText);
		}
	}
	
	/**
	 * Returns the highlighted text.
	 */
	public String getHighlightedText()
	{
		return getHelpPage() == null || SEARCH_RESULT_PROTOCOL.equals(getHelpPage().getProtocol()) ? null
				: this.highlightedText;
	}
	
	/**
	 * Adds a property change listener to <code>preferences</code> to update
	 * displayed page when language changes.
	 */
	private void addLanguageListener(UserPreferences preferences)
	{
		preferences.addPropertyChangeListener(UserPreferences.Property.LANGUAGE, new LanguageChangeListener(this));
	}
	
	/**
	 * Preferences property listener bound to this component with a weak reference to avoid
	 * strong link between preferences and this component.  
	 */
	private static class LanguageChangeListener implements PropertyChangeListener
	{
		private WeakReference<HelpController> helpController;
		
		public LanguageChangeListener(HelpController helpController)
		{
			this.helpController = new WeakReference<HelpController>(helpController);
		}
		
		public void propertyChange(PropertyChangeEvent ev)
		{
			// If help controller was garbage collected, remove this listener from preferences
			HelpController helpController = this.helpController.get();
			if (helpController == null)
			{
				((UserPreferences) ev.getSource()).removePropertyChangeListener(UserPreferences.Property.LANGUAGE,
						this);
			}
			else
			{
				// Updates home page from current default locale
				helpController.history.clear();
				helpController.historyIndex = -1;
				helpController.showPage(helpController.getHelpIndexPageURL());
			}
		}
	}
	
	/**
	 * Controls the display of previous page.
	 */
	public void showPrevious()
	{
		setHelpPage(this.history.get(--this.historyIndex));
		setPreviousPageEnabled(this.historyIndex > 0);
		setNextPageEnabled(true);
	}
	
	/**
	 * Controls the display of next page.
	 */
	public void showNext()
	{
		setHelpPage(this.history.get(++this.historyIndex));
		setPreviousPageEnabled(true);
		setNextPageEnabled(this.historyIndex < this.history.size() - 1);
	}
	
	/**
	 * Controls the display of the given <code>page</code>.
	 */
	public void showPage(URL page)
	{
		if (isBrowserPage(page))
		{
			setBrowserPage(page);
		}
		else if (this.historyIndex == -1 || !this.history.get(this.historyIndex).equals(page))
		{
			setHelpPage(page);
			for (int i = this.history.size() - 1; i > this.historyIndex; i--)
			{
				this.history.remove(i);
			}
			this.history.add(page);
			setPreviousPageEnabled(++this.historyIndex > 0);
			setNextPageEnabled(false);
		}
	}
	
	/**
	 * Returns <code>true</code> if the given <code>page</code> should be displayed 
	 * by the system browser rather than by the help view.
	 * By default, it returns <code>true</code> if the <code>page</code> protocol is http or https.
	 */
	protected boolean isBrowserPage(URL page)
	{
		String protocol = page.getProtocol();
		return protocol.equals("http") || protocol.equals("https");
	}
	
	/**
	 * Returns the URL of the help index page.
	 */
	private URL getHelpIndexPageURL()
	{
		String helpIndex = this.preferences.getLocalizedString(HelpController.class, "helpIndex");
		try
		{
			// Try first to interpret contentFile as an absolute URL 
			return new URL(helpIndex);
		}
		catch (MalformedURLException ex)
		{
			String classPackage = HelpController.class.getName();
			classPackage = classPackage.substring(0, classPackage.lastIndexOf(".")).replace('.', '/');
			String helpIndexWithoutLeadingSlash = helpIndex.startsWith("/") ? helpIndex.substring(1)
					: classPackage + '/' + helpIndex;
			for (ClassLoader classLoader : this.preferences.getResourceClassLoaders())
			{
				try
				{
					return new ResourceURLContent(classLoader, helpIndexWithoutLeadingSlash).getURL();
				}
				catch (IllegalArgumentException ex2)
				{
					// Try next class loader 
				}
			}
			try
			{
				// Build URL of index page with ResourceURLContent because of Java bug #6746185 
				return new ResourceURLContent(HelpController.class, helpIndex).getURL();
			}
			catch (IllegalArgumentException ex2)
			{
				ex2.printStackTrace();
				// Return English help by default
				return new ResourceURLContent(HelpController.class, "resources/help/en/index.html").getURL();
			}
		}
	}
	
	/**
	 * Searches <code>searchedText</code> in help documents and displays 
	 * the result.
	 */
	public void search(String searchedText)
	{
		URL helpIndex = getHelpIndexPageURL();
		String[] searchedWords = getLowerCaseSearchedWords(searchedText);
		List<HelpDocument> helpDocuments = searchInHelpDocuments(helpIndex, searchedWords);
		URL applicationIconUrl = null;
		try
		{
			applicationIconUrl = new ResourceURLContent(HelpController.class,
					"resources/help/images/applicationIcon32.png").getURL();
		}
		catch (Exception ex)
		{
			// Ignore icon
		}
		// Build dynamically the search result page
		final StringBuilder htmlText = new StringBuilder(
				"<html><head><meta http-equiv='content-type' content='text/html;charset=UTF-8'><link href='"
						+ new ResourceURLContent(HelpController.class, "resources/help/help.css").getURL()
						+ "' rel='stylesheet'></head><body bgcolor='#ffffff'>\n"
						+ "<div id='banner'><div id='helpheader'>" + "  <a class='bread' href='" + helpIndex + "'> "
						+ this.preferences.getLocalizedString(HelpController.class, "helpTitle") + "</a>"
						+ "</div></div>" + "<div id='mainbox' align='left'>"
						+ "  <table width='100%' border='0' cellspacing='0' cellpadding='0'>"
						+ "    <tr valign='bottom' height='32'>" + "      <td width='3' height='32'>&nbsp;</td>"
						+ (applicationIconUrl != null ? "<td width='32' height='32'><img src='" + applicationIconUrl
								+ "' height='32' width='32'></td>" : "")
						+ "      <td width='8' height='32'>&nbsp;&nbsp;</td>"
						+ "      <td valign='bottom' height='32'><font id='topic'>"
						+ this.preferences.getLocalizedString(HelpController.class, "searchResult") + "</font></td>"
						+ "    </tr>" + "    <tr height='10'><td colspan='4' height='10'>&nbsp;</td></tr>"
						+ "  </table>" + "  <table width='100%' border='0' cellspacing='0' cellpadding='3'>");
		
		if (helpDocuments.size() == 0)
		{
			String searchNotFound = this.preferences.getLocalizedString(HelpController.class, "searchNotFound",
					searchedText);
			htmlText.append("<tr><td><p>" + searchNotFound + "</td></tr>");
		}
		else
		{
			String searchFound = this.preferences.getLocalizedString(HelpController.class, "searchFound", searchedText);
			htmlText.append("<tr><td colspan='2'><p>" + searchFound + "</td></tr>");
			
			URL searchRelevanceImage = new ResourceURLContent(HelpController.class, "resources/searchRelevance.gif")
					.getURL();
			for (HelpDocument helpDocument : helpDocuments)
			{
				// Add hyperlink to help document found
				htmlText.append("<tr><td valign='middle' nowrap><a href='" + helpDocument.getBase() + "'>"
						+ helpDocument.getTitle() + "</a></td><td valign='middle'>");
				// Add relevance image
				for (int i = 0; i < helpDocument.getRelevance() && i < 50; i++)
				{
					htmlText.append("<img src='" + searchRelevanceImage + "' width='4' height='12'>");
				}
				htmlText.append("</td></tr>");
			}
		}
		htmlText.append("</table></div></body></html>");
		
		try
		{
			// Show built HTML text as a page read from an URL
			showPage(new URL(null, SEARCH_RESULT_PROTOCOL + "://" + htmlText.hashCode(), new URLStreamHandler()
			{
				@Override
				protected URLConnection openConnection(URL url) throws IOException
				{
					return new URLConnection(url)
					{
						@Override
						public void connect() throws IOException
						{
							// Don't need to connect
						}
						
						@Override
						public InputStream getInputStream() throws IOException
						{
							return new ByteArrayInputStream(htmlText.toString().getBytes("UTF-8"));
						}
					};
				}
			}));
		}
		catch (MalformedURLException ex)
		{
			// Can't happen
		}
	}
	
	/**
	 * Returns the searched words in the given text.
	 */
	private String[] getLowerCaseSearchedWords(String searchedText)
	{
		String[] searchedWords = searchedText.split("\\s");
		for (int i = 0; i < searchedWords.length; i++)
		{
			searchedWords[i] = searchedWords[i].toLowerCase().trim();
		}
		return searchedWords;
	}
	
	/**
	 * Searches <code>searchedWords</code> in help documents and returns 
	 * the list of matching documents sorted from the most relevant to the least relevant.
	 * This method uses some Swing classes for their HTML parsing capabilities 
	 * and not to create components.
	 */
	private List<HelpDocument> searchInHelpDocuments(URL helpIndex, String[] searchedWords)
	{
		List<URL> parsedDocuments = new ArrayList<URL>();
		parsedDocuments.add(helpIndex);
		
		List<HelpDocument> helpDocuments = new ArrayList<HelpDocument>();
		// Parse all the URLs added to parsedDocuments at each loop
		for (int i = 0; i < parsedDocuments.size(); i++)
		{
			try
			{
				// Parse a HTML document
				URL helpDocumentUrl = parsedDocuments.get(i);
				HelpDocument helpDocument = new HelpDocument(helpDocumentUrl, searchedWords);
				helpDocument.parse();
				// If searched text was found add it to returned documents list
				if (helpDocument.getRelevance() > 0)
				{
					helpDocuments.add(helpDocument);
				}
				// Check if the HTML file contains new URLs to parse
				for (URL url : helpDocument.getReferencedDocuments())
				{
					String lowerCaseFile = url.getFile().toLowerCase();
					if (lowerCaseFile.endsWith(".html") && !parsedDocuments.contains(url))
					{
						parsedDocuments.add(url);
					}
				}
			}
			catch (IOException ex)
			{
				// Ignore unknown documents (their URLs should be checked outside of Sweet Home 3D)
			}
		}
		// Sort by relevance
		Collections.sort(helpDocuments, new Comparator<HelpDocument>()
		{
			public int compare(HelpDocument document1, HelpDocument document2)
			{
				return document2.getRelevance() - document1.getRelevance();
			}
		});
		return helpDocuments;
	}
	
	/**
	 * A help HTML document parsed with <code>HTMLEditorKit</code>. 
	 */
	private class HelpDocument extends HTMLDocument
	{
		// Documents set referenced in this file 
		private Set<URL> referencedDocuments = new HashSet<URL>();
		private String[] searchedWords;
		private int relevance;
		private String title = "";
		
		public HelpDocument(URL helpDocument, String[] searchedWords)
		{
			this.searchedWords = searchedWords;
			// Store HTML file base
			setBase(helpDocument);
		}
		
		/**
		 * Parses this document. 
		 */
		public void parse() throws IOException
		{
			HTMLEditorKit html = new HTMLEditorKit();
			Reader urlReader = null;
			try
			{
				urlReader = new InputStreamReader(getBase().openStream(), "ISO-8859-1");
				// Parse HTML file first without ignoring charset directive
				putProperty("IgnoreCharsetDirective", Boolean.FALSE);
				try
				{
					html.read(urlReader, this, 0);
				}
				catch (ChangedCharSetException ex)
				{
					// Retrieve document real encoding
					String mimeType = ex.getCharSetSpec();
					String encoding = mimeType.substring(mimeType.indexOf("=") + 1).trim();
					// Restart reading document with its real encoding
					urlReader.close();
					urlReader = new InputStreamReader(getBase().openStream(), encoding);
					putProperty("IgnoreCharsetDirective", Boolean.TRUE);
					html.read(urlReader, this, 0);
				}
			}
			catch (BadLocationException ex)
			{}
			finally
			{
				if (urlReader != null)
				{
					try
					{
						urlReader.close();
					}
					catch (IOException ex)
					{}
				}
			}
		}
		
		public Set<URL> getReferencedDocuments()
		{
			return this.referencedDocuments;
		}
		
		public int getRelevance()
		{
			return this.relevance;
		}
		
		public String getTitle()
		{
			return this.title;
		}
		
		private void addReferencedDocument(String referencedDocument)
		{
			try
			{
				URL url = new URL(getBase(), referencedDocument);
				if (!isBrowserPage(url))
				{
					URL urlWithNoAnchor = new URL(url.getProtocol(), url.getHost(), url.getPort(), url.getFile());
					this.referencedDocuments.add(urlWithNoAnchor);
				}
			}
			catch (MalformedURLException e)
			{
				// Ignore malformed URLs (they should be checked outside of Sweet Home 3D)
			}
		}
		
		@Override
		public HTMLEditorKit.ParserCallback getReader(int pos)
		{
			// Change default callback reader
			return new HelpReader();
		}
		
		// Reader that tracks all <a href=...> tags in current HTML document 
		private class HelpReader extends HTMLEditorKit.ParserCallback
		{
			private boolean inTitle;
			
			@Override
			public void handleStartTag(HTML.Tag tag, MutableAttributeSet att, int pos)
			{
				if (tag.equals(HTML.Tag.A))
				{ // <a href=...> tag
					String attribute = (String) att.getAttribute(HTML.Attribute.HREF);
					if (attribute != null)
					{
						addReferencedDocument(attribute);
					}
				}
				else if (tag.equals(HTML.Tag.TITLE))
				{
					this.inTitle = true;
				}
			}
			
			@Override
			public void handleEndTag(Tag tag, int pos)
			{
				if (tag.equals(HTML.Tag.TITLE))
				{
					this.inTitle = false;
				}
			}
			
			@Override
			public void handleSimpleTag(Tag tag, MutableAttributeSet att, int pos)
			{
				if (tag.equals(HTML.Tag.META))
				{
					String nameAttribute = (String) att.getAttribute(HTML.Attribute.NAME);
					String contentAttribute = (String) att.getAttribute(HTML.Attribute.CONTENT);
					if ("keywords".equalsIgnoreCase(nameAttribute) && contentAttribute != null)
					{
						searchWords(contentAttribute);
					}
				}
			}
			
			@Override
			public void handleText(char[] data, int pos)
			{
				String text = new String(data);
				if (this.inTitle)
				{
					title += text;
				}
				searchWords(text);
			}
			
			private void searchWords(String text)
			{
				String lowerCaseText = text.toLowerCase();
				for (String searchedWord : searchedWords)
				{
					for (int index = 0; index < lowerCaseText.length(); index += searchedWord.length() + 1)
					{
						index = lowerCaseText.indexOf(searchedWord, index);
						if (index == -1)
						{
							break;
						}
						else
						{
							relevance++;
							// Give more relevance to searchedWord when it's found in title
							if (this.inTitle)
							{
								relevance++;
							}
						}
					}
				}
			}
		}
	}
}
